Skip to content

fix(helper-auth): rebind app-password on stale device ID instead of returning 409#1

Open
terafin wants to merge 1 commit into
joeblack2k:mainfrom
intarweb:fix/helper-auth-device-rebind
Open

fix(helper-auth): rebind app-password on stale device ID instead of returning 409#1
terafin wants to merge 1 commit into
joeblack2k:mainfrom
intarweb:fix/helper-auth-device-rebind

Conversation

@terafin

@terafin terafin commented Jun 3, 2026

Copy link
Copy Markdown

Problem

Helper save uploads fail with HTTP 409 "app password is already bound to another device" even when the app-password is correctly bound to the requesting device. This breaks uploads for sgm-steamdeck-helper (and any helper): security_device_state.json shows the correct binding, yet every upload 409s.

Root cause

In authenticateHelperKey (backend/cmd/server/helper_auth.go), CHECK 3 compares the app-password record's stored BoundDeviceID against the device resolved by fingerprint (findDeviceByIdentityLocked) and returns 409 whenever they differ and the stale device ID still exists in memory:

if record.BoundDeviceID != nil && *record.BoundDeviceID != boundDevice.ID {
    if _, exists := a.devices[*record.BoundDeviceID]; exists {
        return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
    }
}

After an RSM restart reloads security_device_state.json, the same physical machine can come back under a new device ID (re-created / re-keyed / inconsistent state). The stored BoundDeviceID and the fingerprint-resolved boundDevice.ID then legitimately diverge, so this guard 409s on a device that is actually the rightful owner — even though the code immediately below CHECK 3 already knows how to rebind the record to the current device.

Fix

  1. CHECK 3 rebind — only return 409 when the stale device is a genuinely different machine (its fingerprint doesn't match the resolved device). Otherwise fall through to the existing rebind path, which repoints the app-password at the current device ID:

    if record.BoundDeviceID != nil && *record.BoundDeviceID != boundDevice.ID {
        if oldDevice, exists := a.devices[*record.BoundDeviceID]; exists {
            if !deviceIdentityMatches(oldDevice.DeviceType, oldDevice.Fingerprint, boundDevice.DeviceType, boundDevice.Fingerprint) {
                return helperAuthContext{}, http.StatusConflict, "app password is already bound to another device"
            }
            // same physical device under a different ID — rebind below
        }
        // old bound device no longer exists (state reload) — rebind below
    }
  2. Orphan cleanupbindAppPasswordToDeviceLocked previously unbound a displaced app-password but left the dangling record in a.appPasswords forever. It now deletes that record when no other device references it (deleteAppPasswordIfOrphanedLocked), preventing unbound/unusable app-passwords from accumulating across repeated (re)bindings.

Tests

New backend/cmd/server/helper_auth_device_binding_test.go:

  • ✅ App-password correctly bound to device → upload succeeds
  • ✅ App-password bound to a stale device ID but same fingerprint → rebinds and succeeds
  • ✅ App-password whose stale device ID belongs to a genuinely different fingerprint → still 409s
  • ✅ Rebinding a device deletes the displaced orphan password
  • ✅ A displaced password still referenced by another device is not deleted

go test ./cmd/server/ passes in full; gofmt and go vet are clean.

Notes for reviewers

The original issue report proposed a second root cause — that bindAppPasswordToDeviceLocked never sets the reciprocal device.AppPasswordID. That doesn't apply to the current source: the function already sets the reciprocal device.BoundAppPasswordID (security_state.go), and appPasswordIDForDeviceLocked keys off the app-password's BoundDeviceID, not a device-side field. The verifiable over-eager 409 is the CHECK 3 path fixed here.

🤖 Generated with Claude Code


Update — also fixes the device_type drift (CHECK 1) + tested on real hardware

The stale-device-ID rebind (CHECK 3) alone wasn't enough: the SGM Steam Deck helper
enrolls with device_type="steamdeck" but uploads saves tagged with the source's
emulator type (device_type="retroarch"), keeping a stable fingerprint. Because
authenticateHelperKey keyed identity on (device_type, fingerprint), every upload
resolved/created a different device and 409'd. This adds a CHECK 1 fix: treat the
fingerprint as the device identity and device_type as variable metadata — same
fingerprint ⇒ same device (rebind cleanly), different fingerprint ⇒ still rejected.

Tested on real hardware: Steam Deck running sgm-steamdeck-helper v0.4.16 (RetroDeck
source) against a build of this branch. Before: every save 409 Conflict. After:
sync … uploaded=3 … errors=0, and a second sync reports in_sync=3. Added unit tests
for both the same-fingerprint/different-device_type (success) and different-fingerprint
(reject) cases.

@terafin terafin force-pushed the fix/helper-auth-device-rebind branch 2 times, most recently from 0dc6a28 to 09d3d61 Compare June 3, 2026 10:15
terafin referenced this pull request in intarweb/RetroSaveManager Jun 7, 2026
…er helper trust

The GBA validator requires saves to carry one of the standard library
signature strings (EEPROM_V / SRAM_V / FLASH_V / FLASH1M_V / FLASH512_V)
or an AGB cartridge header. This assumption breaks for **RetroArch's
libretro-mGBA core** (the default GBA emulator on Steam Deck /
RetroDECK), which does NOT embed those signatures — confirmed against
both an EA Sports title (007 - Everything or Nothing) AND a canonical
mGBA-targeted save (Pokemon Emerald), neither of which contains any
library footer.

Net effect today: every legitimately-saved GBA file from a RetroDECK /
Steam Deck user is rejected with `HTTP 422 - "gba raw save is missing a
validated payload signature"`. See issue joeblack2k#7 for full evidence.

Fix
---

Add `SignatureAdvisoryWithHelperTrust bool` to
`strictRawSaveValidationProfile`. When set AND all of
`detection.Evidence.HelperTrusted` + non-empty `rom_sha1` + non-blank
payload hold, the signature check downgrades from reject to warning. The
warning surfaces in the inspection record so operators can see the
relaxation was applied. Anonymous uploads, uploads without rom_sha1, and
blank payloads continue to reject as before.

Set the flag on the GBA profile only. Other systems (Gameboy / NES /
SNES / NDS) are unaffected.

Trust rationale
---------------

The advisory downgrade only fires under all three conditions
simultaneously:
  1. HelperTrusted — the request came over the authenticated helper
     channel and the helper explicitly asserted the system slug.
  2. rom_sha1 present — the helper computed the ROM hash from a local
     ROM file (not strictly verifiable backend-side but a meaningful
     signal of provenance).
  3. Non-blank payload — rules out freshly-erased FLASH chips and
     truly-empty save buffers.

Random anonymous garbage cannot reach this path. Buggy or malicious
clients still fail #1 unless they hold a valid app password.

Tests
-----

`gba_signature_advisory_test.go` covers:
  - Regression guard: signature PRESENT — accepted, no advisory warning
  - The fix: signature missing + helper trust + rom_sha1 + non-blank — accepted with warning
  - Security guard: signature missing + helper trust but no rom_sha1 — REJECTED
  - Security guard: signature missing + rom_sha1 but no helper trust — REJECTED
  - Security guard: blank payload under helper trust — REJECTED

Full package test suite (15s) passes cleanly with no other regressions.

Fixes joeblack2k#7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
terafin referenced this pull request in intarweb/RetroSaveManager Jun 8, 2026
…er helper trust

The GBA validator requires saves to carry one of the standard library
signature strings (EEPROM_V / SRAM_V / FLASH_V / FLASH1M_V / FLASH512_V)
or an AGB cartridge header. This assumption breaks for **RetroArch's
libretro-mGBA core** (the default GBA emulator on Steam Deck /
RetroDECK), which does NOT embed those signatures — confirmed against
both an EA Sports title (007 - Everything or Nothing) AND a canonical
mGBA-targeted save (Pokemon Emerald), neither of which contains any
library footer.

Net effect today: every legitimately-saved GBA file from a RetroDECK /
Steam Deck user is rejected with `HTTP 422 - "gba raw save is missing a
validated payload signature"`. See issue joeblack2k#7 for full evidence.

Fix
---

Add `SignatureAdvisoryWithHelperTrust bool` to
`strictRawSaveValidationProfile`. When set AND all of
`detection.Evidence.HelperTrusted` + non-empty `rom_sha1` + non-blank
payload hold, the signature check downgrades from reject to warning. The
warning surfaces in the inspection record so operators can see the
relaxation was applied. Anonymous uploads, uploads without rom_sha1, and
blank payloads continue to reject as before.

Set the flag on the GBA profile only. Other systems (Gameboy / NES /
SNES / NDS) are unaffected.

Trust rationale
---------------

The advisory downgrade only fires under all three conditions
simultaneously:
  1. HelperTrusted — the request came over the authenticated helper
     channel and the helper explicitly asserted the system slug.
  2. rom_sha1 present — the helper computed the ROM hash from a local
     ROM file (not strictly verifiable backend-side but a meaningful
     signal of provenance).
  3. Non-blank payload — rules out freshly-erased FLASH chips and
     truly-empty save buffers.

Random anonymous garbage cannot reach this path. Buggy or malicious
clients still fail #1 unless they hold a valid app password.

Tests
-----

`gba_signature_advisory_test.go` covers:
  - Regression guard: signature PRESENT — accepted, no advisory warning
  - The fix: signature missing + helper trust + rom_sha1 + non-blank — accepted with warning
  - Security guard: signature missing + helper trust but no rom_sha1 — REJECTED
  - Security guard: signature missing + rom_sha1 but no helper trust — REJECTED
  - Security guard: blank payload under helper trust — REJECTED

Full package test suite (15s) passes cleanly with no other regressions.

Fixes joeblack2k#7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
terafin referenced this pull request in intarweb/RetroSaveManager Jun 8, 2026
…er helper trust

The GBA validator requires saves to carry one of the standard library
signature strings (EEPROM_V / SRAM_V / FLASH_V / FLASH1M_V / FLASH512_V)
or an AGB cartridge header. This assumption breaks for **RetroArch's
libretro-mGBA core** (the default GBA emulator on Steam Deck /
RetroDECK), which does NOT embed those signatures — confirmed against
both an EA Sports title (007 - Everything or Nothing) AND a canonical
mGBA-targeted save (Pokemon Emerald), neither of which contains any
library footer.

Net effect today: every legitimately-saved GBA file from a RetroDECK /
Steam Deck user is rejected with `HTTP 422 - "gba raw save is missing a
validated payload signature"`. See issue joeblack2k#7 for full evidence.

Fix
---

Add `SignatureAdvisoryWithHelperTrust bool` to
`strictRawSaveValidationProfile`. When set AND all of
`detection.Evidence.HelperTrusted` + non-empty `rom_sha1` + non-blank
payload hold, the signature check downgrades from reject to warning. The
warning surfaces in the inspection record so operators can see the
relaxation was applied. Anonymous uploads, uploads without rom_sha1, and
blank payloads continue to reject as before.

Set the flag on the GBA profile only. Other systems (Gameboy / NES /
SNES / NDS) are unaffected.

Trust rationale
---------------

The advisory downgrade only fires under all three conditions
simultaneously:
  1. HelperTrusted — the request came over the authenticated helper
     channel and the helper explicitly asserted the system slug.
  2. rom_sha1 present — the helper computed the ROM hash from a local
     ROM file (not strictly verifiable backend-side but a meaningful
     signal of provenance).
  3. Non-blank payload — rules out freshly-erased FLASH chips and
     truly-empty save buffers.

Random anonymous garbage cannot reach this path. Buggy or malicious
clients still fail #1 unless they hold a valid app password.

Tests
-----

`gba_signature_advisory_test.go` covers:
  - Regression guard: signature PRESENT — accepted, no advisory warning
  - The fix: signature missing + helper trust + rom_sha1 + non-blank — accepted with warning
  - Security guard: signature missing + helper trust but no rom_sha1 — REJECTED
  - Security guard: signature missing + rom_sha1 but no helper trust — REJECTED
  - Security guard: blank payload under helper trust — REJECTED

Full package test suite (15s) passes cleanly with no other regressions.

Fixes joeblack2k#7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
terafin referenced this pull request in intarweb/RetroSaveManager Jun 8, 2026
…er helper trust

The GBA validator requires saves to carry one of the standard library
signature strings (EEPROM_V / SRAM_V / FLASH_V / FLASH1M_V / FLASH512_V)
or an AGB cartridge header. This assumption breaks for **RetroArch's
libretro-mGBA core** (the default GBA emulator on Steam Deck /
RetroDECK), which does NOT embed those signatures — confirmed against
both an EA Sports title (007 - Everything or Nothing) AND a canonical
mGBA-targeted save (Pokemon Emerald), neither of which contains any
library footer.

Net effect today: every legitimately-saved GBA file from a RetroDECK /
Steam Deck user is rejected with `HTTP 422 - "gba raw save is missing a
validated payload signature"`. See issue joeblack2k#7 for full evidence.

Fix
---

Add `SignatureAdvisoryWithHelperTrust bool` to
`strictRawSaveValidationProfile`. When set AND all of
`detection.Evidence.HelperTrusted` + non-empty `rom_sha1` + non-blank
payload hold, the signature check downgrades from reject to warning. The
warning surfaces in the inspection record so operators can see the
relaxation was applied. Anonymous uploads, uploads without rom_sha1, and
blank payloads continue to reject as before.

Set the flag on the GBA profile only. Other systems (Gameboy / NES /
SNES / NDS) are unaffected.

Trust rationale
---------------

The advisory downgrade only fires under all three conditions
simultaneously:
  1. HelperTrusted — the request came over the authenticated helper
     channel and the helper explicitly asserted the system slug.
  2. rom_sha1 present — the helper computed the ROM hash from a local
     ROM file (not strictly verifiable backend-side but a meaningful
     signal of provenance).
  3. Non-blank payload — rules out freshly-erased FLASH chips and
     truly-empty save buffers.

Random anonymous garbage cannot reach this path. Buggy or malicious
clients still fail #1 unless they hold a valid app password.

Tests
-----

`gba_signature_advisory_test.go` covers:
  - Regression guard: signature PRESENT — accepted, no advisory warning
  - The fix: signature missing + helper trust + rom_sha1 + non-blank — accepted with warning
  - Security guard: signature missing + helper trust but no rom_sha1 — REJECTED
  - Security guard: signature missing + rom_sha1 but no helper trust — REJECTED
  - Security guard: blank payload under helper trust — REJECTED

Full package test suite (15s) passes cleanly with no other regressions.

Fixes joeblack2k#7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
terafin referenced this pull request in intarweb/RetroSaveManager Jun 8, 2026
…er helper trust

The GBA validator requires saves to carry one of the standard library
signature strings (EEPROM_V / SRAM_V / FLASH_V / FLASH1M_V / FLASH512_V)
or an AGB cartridge header. This assumption breaks for **RetroArch's
libretro-mGBA core** (the default GBA emulator on Steam Deck /
RetroDECK), which does NOT embed those signatures — confirmed against
both an EA Sports title (007 - Everything or Nothing) AND a canonical
mGBA-targeted save (Pokemon Emerald), neither of which contains any
library footer.

Net effect today: every legitimately-saved GBA file from a RetroDECK /
Steam Deck user is rejected with `HTTP 422 - "gba raw save is missing a
validated payload signature"`. See issue joeblack2k#7 for full evidence.

Fix
---

Add `SignatureAdvisoryWithHelperTrust bool` to
`strictRawSaveValidationProfile`. When set AND all of
`detection.Evidence.HelperTrusted` + non-empty `rom_sha1` + non-blank
payload hold, the signature check downgrades from reject to warning. The
warning surfaces in the inspection record so operators can see the
relaxation was applied. Anonymous uploads, uploads without rom_sha1, and
blank payloads continue to reject as before.

Set the flag on the GBA profile only. Other systems (Gameboy / NES /
SNES / NDS) are unaffected.

Trust rationale
---------------

The advisory downgrade only fires under all three conditions
simultaneously:
  1. HelperTrusted — the request came over the authenticated helper
     channel and the helper explicitly asserted the system slug.
  2. rom_sha1 present — the helper computed the ROM hash from a local
     ROM file (not strictly verifiable backend-side but a meaningful
     signal of provenance).
  3. Non-blank payload — rules out freshly-erased FLASH chips and
     truly-empty save buffers.

Random anonymous garbage cannot reach this path. Buggy or malicious
clients still fail #1 unless they hold a valid app password.

Tests
-----

`gba_signature_advisory_test.go` covers:
  - Regression guard: signature PRESENT — accepted, no advisory warning
  - The fix: signature missing + helper trust + rom_sha1 + non-blank — accepted with warning
  - Security guard: signature missing + helper trust but no rom_sha1 — REJECTED
  - Security guard: signature missing + rom_sha1 but no helper trust — REJECTED
  - Security guard: blank payload under helper trust — REJECTED

Full package test suite (15s) passes cleanly with no other regressions.

Fixes joeblack2k#7

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…eturning 409

[squashed; see PR body for full description]

🤖 Generated with [Claude Code](https://claude.com/claude-code)
@terafin terafin force-pushed the fix/helper-auth-device-rebind branch from e580cb7 to 50a7709 Compare June 8, 2026 18:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant